// main.js: 实现登录/注册（基于 config.json 或 localStorage）、计时器、记录管理、实况和导出
// Switch to async IIFE so we can use await for fetch-based storage
(async function(){
  // Compute config.json path relative to the current page so the script works regardless of URL prefix
  const cfgPath = (typeof window !== 'undefined') ? new URL('config.json', window.location.href).toString() : '/config.json';

  // Helpers
  function qs(id){ return document.getElementById(id); }
  function readConfig(cb){
    // append a timestamp and request no-store to avoid stale cached config.json
    const url = cfgPath + (cfgPath.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now();
    fetch(url, { cache: 'no-store' }).then(r=>{
      if (!r.ok) throw new Error('no');
      return r.json();
    }).then(cfg=>{
      cb(cfg);
    }).catch(()=>{ const fallback = {allowed_users:{}}; cb(fallback); });
  }

  // Short display format for UI: if the timestamp is today, show only HH:MM;
  // otherwise show MM-DD HH:MM. This keeps table columns compact on mobile.
  function formatShort(iso){
    if (!iso) return '';
    const d = parseLocalDateTime(iso) || new Date(iso);
    if (!d || isNaN(d)) return '';
    const hh = pad(d.getHours()); const mm = pad(d.getMinutes());
    const datePart = `${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
    const timePart = `${hh}:${mm}`;
    // return single-line compact format: MM-DD HH:MM (no year)
    return `${datePart} ${timePart}`;
  }

  function nowISO(){ return new Date().toISOString(); }
  // format an ISO datetime string into the browser's local timezone (YYYY-MM-DD HH:MM:SS)
  function pad(n){ return n<10? '0'+n : String(n); }
  // format an ISO datetime string into Beijing time (Asia/Shanghai) YYYY-MM-DD HH:MM:SS
  function formatLocal(iso){
    if (!iso) return '';
    // If the timestamp string has no timezone marker (common for MySQL DATETIME
    // that already represents local/Beijing time), just normalize and return it
    // so DB and display match exactly. If it includes a timezone (Z or +HH:MM),
    // parse and convert to Asia/Shanghai for display.
    let s = String(iso).trim();
    const hasTZ = /[zZ]|[+\-]\d{2}(:?\d{2})?$/.test(s);
    if (!hasTZ){
      // normalize format: replace T with space and strip fractional seconds
      s = s.replace('T',' ').split('.')[0];
      return s;
    }
    // has timezone info: parse and convert to Beijing time for display
    let d = new Date(s);
    if (isNaN(d)) return iso;
    try{
      const df = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false });
      if (typeof df.formatToParts === 'function'){
        const parts = df.formatToParts(d);
        const map = {};
        parts.forEach(p=> map[p.type] = p.value);
        return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}:${map.second}`;
      }
      const out = df.format(d);
      return out.replace(/\//g,'-').replace(/\u200E/g,'');
    }catch(e){
      const ms = d.getTime();
      const beijing = new Date(ms + 8*3600*1000);
      return beijing.getFullYear() + '-' + pad(beijing.getMonth()+1) + '-' + pad(beijing.getDate()) + ' ' + pad(beijing.getHours()) + ':' + pad(beijing.getMinutes()) + ':' + pad(beijing.getSeconds());
    }
  }

  // Parse a datetime string coming from the DB. If the string contains a timezone
  // marker (Z or +HH:MM) parse normally. If it lacks timezone (typical MySQL DATETIME),
  // interpret it as a local date/time by constructing a Date from components so
  // comparisons (today/week) behave consistently.
  function parseLocalDateTime(str){
    if (!str) return null;
    const s0 = String(str).trim();
    // has timezone marker?
    const hasTZ = /[zZ]|[+\-]\d{2}(:?\d{2})?$/.test(s0);
    if (hasTZ) return new Date(s0);
    // normalize and strip fractional seconds
    const s = s0.split('.')[0].replace('T',' ');
    // accept HH:MM or HH:MM:SS
    const m = s.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
    if (m){
      const sec = m[6] ? Number(m[6]) : 0;
      // Interpret bare DB datetime as Beijing local time (UTC+8). Convert to a JS Date
      // representing the correct absolute instant by subtracting 8 hours from UTC timestamp.
      const utcMs = Date.UTC(Number(m[1]), Number(m[2])-1, Number(m[3]), Number(m[4]), Number(m[5]), sec);
      const beijingUtcMs = utcMs - 8*3600*1000;
      return new Date(beijingUtcMs);
    }
    const d = new Date(s);
    return isNaN(d)? null : d;
  }

  // Helpers to compute a Beijing date key (YYYYMMDD) from a millisecond timestamp or Date
  function beijingKeyFromMs(ms){
    if (ms == null) return null;
    // shift UTC ms by +8h so UTC fields represent Beijing local fields
    const b = new Date(ms + 8*3600*1000);
    const y = b.getUTCFullYear(); const m = b.getUTCMonth()+1; const d = b.getUTCDate();
    return y*10000 + m*100 + d;
  }
  function beijingKeyFromDate(d){ return d ? beijingKeyFromMs(d.getTime()) : null; }
  function getTodayKey(){ return beijingKeyFromMs(Date.now()); }
  function getWeekStartKey(){
    // compute Beijing local date, then find Monday and return its key
    const nowB = new Date(Date.now() + 8*3600*1000);
    const day = nowB.getUTCDay(); // 0..6 (Sunday)
    const diff = (day === 0 ? -6 : 1) - day; // offset to Monday
    const mondayB = new Date(Date.UTC(nowB.getUTCFullYear(), nowB.getUTCMonth(), nowB.getUTCDate() + diff));
    // mondayB currently in UTC representing Beijing Monday 00:00; convert to key
    // mondayB.getTime() is already the UTC ms for that day at 00:00 Beijing
    return beijingKeyFromMs(mondayB.getTime());
  }
  // helper: get Beijing YYYYMMDD key from a datetime string (DB value)
  function beijingKeyFromString(str){
    const d = parseLocalDateTime(str);
    return d ? beijingKeyFromDate(d) : null;
  }

  // render a top navbar before <main>
  async function renderNavbar(){
    try{
      const mainEl = document.querySelector('main');
      if (!mainEl) return;
      // don't double-insert
      if (document.querySelector('.navbar')) return;
      const nav = document.createElement('nav'); nav.className='navbar';
  const brand = document.createElement('div'); brand.className='brand'; brand.innerHTML = '';
  const links = document.createElement('div'); links.className='nav-links';
  const toggle = document.createElement('button'); toggle.className='nav-toggle'; toggle.setAttribute('aria-label','菜单');
  // ensure this button doesn't act as a form submit and expose expanded state for accessibility
  toggle.type = 'button'; toggle.setAttribute('aria-expanded','false');
  toggle.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6h16M4 12h16M4 18h16" stroke="#0b2740" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
      const s = await getSession();
      // hide brand on auth page; otherwise hide when logged in
      const currentPath = window.location.pathname.split('/').pop();
      const hideBrandOnAuth = (currentPath === 'auth.html');
      if (s){
        links.innerHTML = `<a href="live.html">劳动实况</a><a href="timer.html">计时</a><a id="nav-logout" href="#">退出</a>`;
        brand.style.display = 'none';
      }else{
        links.innerHTML = `<a href="auth.html">登录/注册</a>`;
        if (!hideBrandOnAuth){ brand.innerHTML = '<a href="index.php">任务管理</a>'; brand.style.display = ''; }
        else { brand.innerHTML = ''; brand.style.display = 'none'; }
      }
  nav.appendChild(brand); nav.appendChild(toggle); nav.appendChild(links);
      mainEl.parentNode.insertBefore(nav, mainEl);
  // toggle behavior (also update aria-expanded)
  toggle.addEventListener('click', ()=>{ const open = nav.classList.toggle('open'); toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); });
  // keyboard accessibility: allow Enter/Space to toggle
  toggle.addEventListener('keydown', (e)=>{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); } });
      const logout = document.getElementById('nav-logout');
  if (logout) logout.addEventListener('click', async (e)=>{ e.preventDefault(); await fetch('api.php?action=logout',{method:'POST',credentials:'include'}); location.href='auth.html'; });
  // close menu when any link clicked (mobile friendly)
  links.addEventListener('click', (e)=>{ if (e.target.tagName === 'A') nav.classList.remove('open'); });
    }catch(e){ console.error(e); }
  }

  // render navbar as early as possible
  renderNavbar();

  // Storage helpers now use server-side MySQL via api.php
  async function loadLabors(){
    const r = await fetch('api.php?action=labors', {credentials: 'include'});
    if (!r.ok) return [];
    const j = await r.json();
    return j.labors || [];
  }

  // Global client-side route guard: only allow unauthenticated access to auth.html and init.php
  (async function(){
    try{
      const publicFiles = ['auth.html','init.php'];
      const path = window.location.pathname.split('/').pop();
      if (!publicFiles.includes(path)){
        const s = await getSession();
        if (!s){ location.href = 'auth.html'; }
      }
    }catch(e){ /* ignore and allow auth page to load */ }
  })();

  // create a new labor entry on server
  async function createLabor(entry){
    const r = await fetch('api.php?action=create_labor', {
      method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
      body: JSON.stringify(entry)
    });
    const txt = await r.text();
    try{
      const j = JSON.parse(txt);
      if (!r.ok) { console.error('createLabor error response:', j); return j; }
      return j;
    }catch(e){
      console.error('createLabor non-json response:', txt);
      throw new Error('createLabor: non-json response');
    }
  }

  // update labor by id
  async function updateLabor(id, fields){
    const payload = Object.assign({id}, fields);
    const r = await fetch('api.php?action=update_labor', {
      method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
      body: JSON.stringify(payload)
    });
    const txt = await r.text();
    try{
      const j = JSON.parse(txt);
      if (!r.ok) { console.error('updateLabor error response:', j); return j; }
      return j;
    }catch(e){
      console.error('updateLabor non-json response:', txt);
      throw new Error('updateLabor: non-json response');
    }
  }

  // delete labor by id
  async function deleteLabor(id){
    if (!id) return {ok:false};
    const r = await fetch('api.php?action=delete_labor', {
      method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
      body: JSON.stringify({id: id})
    });
    const txt = await r.text();
    try{
      const j = JSON.parse(txt);
      if (!r.ok) { console.error('deleteLabor error response:', j); return j; }
      return j;
    }catch(e){
      console.error('deleteLabor non-json response:', txt);
      return {ok:false};
    }
  }

  // simple reusable confirm modal. showConfirm(message) returns a Promise<boolean>.
  function createConfirmModal(){
    if (qs('confirmModal')) return;
    const modal = document.createElement('div'); modal.id = 'confirmModal';
    modal.style.position = 'fixed'; modal.style.left = 0; modal.style.top = 0; modal.style.right = 0; modal.style.bottom = 0;
    modal.style.background = 'rgba(0,0,0,0.4)'; modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; modal.style.zIndex = 999999;
    const box = document.createElement('div'); box.style.background='white'; box.style.padding='16px'; box.style.borderRadius='8px'; box.style.minWidth='260px'; box.style.boxShadow='0 8px 24px rgba(0,0,0,0.2)';
    box.innerHTML = `<div style="display:flex;flex-direction:column;gap:12px"><div id="confirmMsg" style="font-size:15px">确认操作</div><div style="display:flex;gap:8px;justify-content:flex-end"><button id="confirmCancel" class="btn">取消</button><button id="confirmOk" class="btn primary">确定</button></div></div>`;
    modal.appendChild(box); document.body.appendChild(modal);
    // accessibility / cleanup
    modal.addEventListener('click', (e)=>{ if (e.target === modal) modal.style.display='none'; });
    document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape'){ const m = qs('confirmModal'); if (m) m.style.display='none'; } });
  }
  createConfirmModal();

  function showConfirm(message){
    return new Promise((resolve)=>{
      createConfirmModal();
      const modal = qs('confirmModal'); const msg = qs('confirmMsg'); const ok = qs('confirmOk'); const cancel = qs('confirmCancel');
      if (!modal || !msg || !ok || !cancel) return resolve(false);
      msg.textContent = message || '';
      modal.style.display = 'flex';
      // ensure focus
      ok.focus();
      function cleanup(){ modal.style.display='none'; ok.removeEventListener('click', onOk); cancel.removeEventListener('click', onCancel); }
      function onOk(){ cleanup(); resolve(true); }
      function onCancel(){ cleanup(); resolve(false); }
      ok.addEventListener('click', onOk);
      cancel.addEventListener('click', onCancel);
    });
  }

  // Create a reusable datetime picker modal (works across browsers including Firefox).
  // showDateTimeModal(initialFull, allowClear) -> Promise<{action:'ok'|'clear'|'cancel', value?: 'YYYY-MM-DD HH:MM:SS'}>
  function createDateTimeModal(){
    if (qs('dateTimeModal')) return;
    const modal = document.createElement('div'); modal.id = 'dateTimeModal';
    modal.style.position = 'fixed'; modal.style.left = 0; modal.style.top = 0; modal.style.right = 0; modal.style.bottom = 0;
    modal.style.background = 'rgba(0,0,0,0.4)'; modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; modal.style.zIndex = 1000000;
    const box = document.createElement('div'); box.style.background='white'; box.style.padding='14px'; box.style.borderRadius='8px'; box.style.minWidth='280px'; box.style.boxShadow='0 8px 24px rgba(0,0,0,0.2)';
    box.innerHTML = `
      <div style="display:flex;flex-direction:column;gap:10px">
        <div style="font-size:15px">选择日期和时间</div>
        <div style="display:flex;gap:8px">
          <input id="dt_date" type="date" style="flex:1;padding:6px" />
          <input id="dt_time" type="time" step="60" style="width:110px;padding:6px" />
        </div>
        <div style="display:flex;gap:8px;justify-content:flex-end">
          <button id="dt_cancel" class="btn">取消</button>
          <button id="dt_clear" class="btn">清空</button>
          <button id="dt_ok" class="btn primary">确定</button>
        </div>
      </div>`;
    modal.appendChild(box); document.body.appendChild(modal);
    // click outside to cancel
    modal.addEventListener('click', (e)=>{ if (e.target === modal) modal.style.display='none'; });
    // escape to cancel
    document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape'){ const m = qs('dateTimeModal'); if (m) m.style.display='none'; } });
  }
  createDateTimeModal();

  function showDateTimeModal(initialFull, allowClear){
    return new Promise((resolve)=>{
      createDateTimeModal();
      const modal = qs('dateTimeModal'); if (!modal) return resolve({action:'cancel'});
      const dateInput = qs('dt_date'); const timeInput = qs('dt_time'); const btnOk = qs('dt_ok'); const btnCancel = qs('dt_cancel'); const btnClear = qs('dt_clear');
      // parse initial value (DB format 'YYYY-MM-DD HH:MM:SS' or ISO)
      let pd = parseLocalDateTime(initialFull) || new Date(initialFull || '');
      if (pd && !isNaN(pd)){
        // fill date/time as local fields
        const yy = pd.getFullYear(); const mm = pad(pd.getMonth()+1); const dd = pad(pd.getDate());
        const hh = pad(pd.getHours()); const min = pad(pd.getMinutes());
        dateInput.value = `${yy}-${mm}-${dd}`;
        timeInput.value = `${hh}:${min}`;
      }else{
        dateInput.value = '';
        timeInput.value = '';
      }
      // show/enable clear button depending on allowClear
      btnClear.style.display = allowClear ? '' : 'none';
      modal.style.display = 'flex';
      // focus
      setTimeout(()=>{ dateInput.focus(); }, 50);
      function cleanup(){ modal.style.display='none'; btnOk.removeEventListener('click', onOk); btnCancel.removeEventListener('click', onCancel); btnClear.removeEventListener('click', onClear); }
      function onOk(){
        const d = dateInput.value; const t = timeInput.value;
        if (!d){ alert('请选择日期'); return; }
        // default time to 00:00 if empty
        const tt = (t && t.length>=4) ? t : '00:00';
        // produce 'YYYY-MM-DD HH:MM:SS'
        const out = `${d} ${tt}:00`;
        cleanup(); resolve({action:'ok', value: out});
      }
      function onClear(){ cleanup(); resolve({action:'clear'}); }
      function onCancel(){ cleanup(); resolve({action:'cancel'}); }
      btnOk.addEventListener('click', onOk); btnCancel.addEventListener('click', onCancel); btnClear.addEventListener('click', onClear);
    });
  }

  async function loadUsers(){
    const r = await fetch('api.php?action=users', {credentials: 'include'});
    if (!r.ok) return {};
    const j = await r.json();
    // return object keyed by wechat id for compatibility with existing code
    const obj = {};
    (j.users||[]).forEach(u=> obj[u.wechat_id] = {nickname: u.nickname});
    return obj;
  }

  // Session is managed server-side; this returns {wechat,nickname} or null
  async function getSession(){
    const r = await fetch('api.php?action=session', {credentials: 'include'});
    if (!r.ok) return null;
    const j = await r.json();
    return j.session || null;
  }

  // front-end helper to perform login/register via API
  async function apiLogin(wechat, password){
    const r = await fetch('api.php?action=login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wechat, password})});
    return r.ok ? await r.json() : {ok:false};
  }
  async function apiRegister(wechat, nickname, password){
    const r = await fetch('api.php?action=register', {method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({wechat, nickname, password})});
    return r.ok ? await r.json() : {ok:false};
  }

  // Auth page
  if (qs('btn_login')){
    const msg = qs('msg');
    // keep readConfig only for allowed_users check on registration
    readConfig(cfg=>{
      const allowed = cfg.allowed_users || {};
      
      function isAllowed(allowedList, id){
        if (!allowedList) return false;
        if (Array.isArray(allowedList)) return allowedList.indexOf(id) !== -1;
        if (typeof allowedList === 'object') return Boolean(allowedList[id]);
        return false;
      }
      qs('btn_login').addEventListener('click', async ()=>{
        const wechat = qs('login_wechat').value.trim();
        const pwd = qs('login_pwd').value;
        const res = await apiLogin(wechat, pwd);
        if (res.ok){ msg.innerText = '登录成功'; location.href = 'timer.html'; }
        else msg.innerText = '登录失败：用户不存在或密码错误';
      });

      qs('btn_reg').addEventListener('click', async ()=>{
        const wechat = qs('reg_wechat').value.trim();
        const nick = qs('reg_nick').value.trim();
        const pwd = qs('reg_pwd').value;
        // simple checks
        if (!wechat || !pwd){ msg.innerText='请输入账号和密码'; return; }
        // re-fetch config.json now to ensure we use latest allowed_users (avoid needing Ctrl+F5)
        readConfig(async (cfg)=>{
          const freshAllowed = (cfg && cfg.allowed_users) ? cfg.allowed_users : {};
          // allowed list enforcement
          const allowedCheck = isAllowed(freshAllowed, wechat);
          if (!allowedCheck){ msg.innerText='不在允许注册名单中，请先在 init.php 中添加'; return; }
          const defaultNick = nick || wechat;
          const res = await apiRegister(wechat, defaultNick, pwd);
          if (res.ok) msg.innerText='注册成功，请登录'; else msg.innerText='注册失败';
        });
      });
    });
  }

  // Timer page
  if (qs('bigBtn')){
    const bigBtn = qs('bigBtn');
    const userInfo = qs('userInfo');
    async function renderUser(){
      const s = await getSession();
      if (s) userInfo.innerText = `当前用户：${s.nickname} (${s.wechat})`;
      else userInfo.innerText = '未登录（请先登录）';
    }
    await renderUser();

    async function refreshHistory(){
        const s = await getSession();
        if (!s){ location.href = 'auth.html'; return; }
  const all = await loadLabors();
  const my = all.filter(l=> l.worker_wechat === s.wechat);
  // sort by start_at descending (newest first)
  my.sort((a,b)=>{ const da = parseLocalDateTime(a.start_at) || 0; const db = parseLocalDateTime(b.start_at) || 0; return db - da; });
  const hist = my.slice(0,20);
        if (!hist.length){ qs('history').innerHTML = '<h3>最近记录</h3><div class="note">无</div>'; return; }
      let rows = '';
      hist.forEach(h=>{
        const startStr = h.start_at? formatShort(h.start_at) : '';
        const endStr = h.end_at? formatShort(h.end_at) : '';
        // compute hours if possible
        let hoursVal = h.hours;
        if ((hoursVal === undefined || hoursVal === null || hoursVal === '') && h.start_at && h.end_at){
          const st = parseLocalDateTime(h.start_at); const ed = parseLocalDateTime(h.end_at);
          let hours = (ed - st)/3600000; if (hours < 0) hours += 24; hoursVal = Math.round(hours*100)/100;
        }
        const weightVal = (h.weight!==undefined)? h.weight : 1;
        const pointsVal = (hoursVal !== undefined && hoursVal !== null && hoursVal !== '') ? Math.round(hoursVal * (weightVal||1) * 100)/100 : (h.points || '');
          // new column order: start, end, nickname, name, hours, weight, points
          const nick = h.nickname || h.worker_wechat;
          const startCell = `<span class="editable editable-datetime" data-id="${h.id}" data-field="start_at" data-full="${formatLocal(h.start_at)}">${startStr}</span>`;
          const endCell = `<span class="editable editable-datetime" data-id="${h.id}" data-field="end_at" data-full="${formatLocal(h.end_at)}">${endStr||'进行中'}</span>`;
          const nameCell = `<span class="editable editable-text" data-id="${h.id}" data-field="name">${h.name||''}</span>`;
          const weightCell = `<span class="editable editable-number" data-id="${h.id}" data-field="weight">${(weightVal||1)}</span>`;
          rows += `<tr><td>${startCell}</td><td>${endCell}</td><td>${nick}</td><td>${nameCell}</td><td>${hoursVal!=null? (typeof hoursVal==='number'?hoursVal.toFixed(2):hoursVal):''}</td><td>${weightCell}</td><td>${(typeof pointsVal==='number')?pointsVal.toFixed(2):pointsVal}</td></tr>`;
      });
      const html = `
        <h3>最近记录</h3>
        <div class="card">
          <table>
            <thead><tr><th>开始时间</th><th>结束时间</th><th>用户昵称</th><th>劳动内容</th><th>工时</th><th>权重</th><th>工分</th></tr></thead>
            <tbody>${rows}</tbody>
          </table>
        </div>`;
      qs('history').innerHTML = html;
      // attach editing handlers for newly created editable cells
      const historyEl = qs('history');

      // helper: start editing given editable span (shared so td dblclick can reuse)
      function startEdit(span){
        return (async function(){
          const id = span.dataset.id; const field = span.dataset.field;
          // create input
          const origFull = span.dataset.full || span.textContent || '';
          const input = document.createElement('input'); input.className='inline-edit';
          // if editing datetime, show modal picker (works across browsers). For other fields use inline input.
          if (field==='start_at' || field==='end_at'){
            // show modal picker with initial value from data-full (DB format) and allow clearing for end_at
            const allowClear = (field==='end_at');
            const res = await showDateTimeModal(origFull, allowClear);
            if (!res || res.action === 'cancel') return;
            // prepare payload based on action
            const payload = {};
            if (res.action === 'clear'){
              if (field === 'end_at') payload[field] = null;
            }else if (res.action === 'ok'){
              // res.value is 'YYYY-MM-DD HH:MM:SS'
              payload[field] = res.value;
            }
            // now reuse same update logic as below: compute hours/points and send update
            try{
              // determine effective start/end/weight after edit
              const row = span.closest('tr');
              const startCell = row.querySelector('[data-field="start_at"]');
              const endCell = row.querySelector('[data-field="end_at"]');
              const weightCell = row.querySelector('[data-field="weight"]');
              let startVal = (field==='start_at') ? (payload['start_at'] || null) : (startCell ? (startCell.dataset.full || startCell.textContent) : null);
              let endVal = (field==='end_at') ? (payload['end_at'] || null) : (endCell ? (endCell.dataset.full || endCell.textContent) : null);
              let weightVal = (field==='weight') ? payload['weight'] : (weightCell? parseFloat(weightCell.textContent) : 1);
              const pdStart = startVal ? parseLocalDateTime(startVal) : null;
              const pdEnd = endVal ? parseLocalDateTime(endVal) : null;
              if (pdStart && pdEnd){
                let hours = (pdEnd - pdStart)/3600000; if (hours < 0) hours += 24; hours = Math.round(hours*100)/100; payload['hours'] = hours; payload['points'] = Math.round(hours * (weightVal||1) * 100)/100;
              }else{
                if (payload.hasOwnProperty('start_at') || payload.hasOwnProperty('end_at')){ payload['hours'] = null; payload['points'] = null; }
              }
              const resUp = await updateLabor(id, payload);
              if (!resUp || !resUp.ok){ alert('更新失败'); return; }
              // on success, update DOM cells: update span text and data-full
              if (payload[field] !== undefined){
                if (field==='start_at' || field==='end_at'){
                  const target = row.querySelector(`[data-field="${field}"]`);
                  const full = payload[field]===null? '' : payload[field];
                  target.dataset.full = full;
                  target.textContent = full? formatShort(full) : (field==='end_at'? '进行中' : '');
                }else{
                  const target = row.querySelector(`[data-field="${field}"]`);
                  target.textContent = payload[field];
                }
              }
              if (payload.hasOwnProperty('hours')){ const hoursTd = row.children[4]; hoursTd.textContent = payload.hours? (payload.hours.toFixed? payload.hours.toFixed(2): payload.hours): ''; }
              if (payload.hasOwnProperty('points')){ const pointsTd = row.children[6]; pointsTd.textContent = payload.points? (payload.points.toFixed? payload.points.toFixed(2): payload.points): ''; }
            }catch(err){ console.error(err); alert('更新出错'); }
            return;
          }else{
            input.type = 'text'; input.value = origFull;
          }
          // replace span content
          span.style.display='none'; span.parentNode.insertBefore(input, span);
          input.focus(); if (typeof input.select === 'function') input.select();
          // ensure input is visible above mobile keyboard: add class and scroll into view
          document.body.classList.add('keyboard-open');
          setTimeout(()=>{ try{ input.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){} }, 250);
          async function finish(save){
            const newVal = input.value.trim();
            input.remove(); span.style.display='';
            if (!save) return;
            // prepare payload
            const payload = {};
            try{
              if (field==='start_at' || field==='end_at'){
                // allow clearing end_at by empty value
                if (newVal === '' && field==='end_at'){
                  payload[field] = null;
                }else{
                  // if using datetime-local input, value looks like YYYY-MM-DDTHH:MM
                  let pd = null;
                  if (input.type === 'datetime-local'){
                    // append :00 seconds
                    const v = newVal.indexOf('T')!==-1 ? newVal : (newVal.replace(' ', 'T'));
                    pd = parseLocalDateTime(v) || new Date(v);
                  }else{
                    pd = parseLocalDateTime(newVal) || parseLocalDateTime(span.dataset.full);
                  }
                  if (!pd) { alert('无法解析日期时间，请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DD HH:MM:SS'); return; }
                  const f = pd.getFullYear() + '-' + pad(pd.getMonth()+1) + '-' + pad(pd.getDate()) + ' ' + pad(pd.getHours()) + ':' + pad(pd.getMinutes()) + ':' + pad(pd.getSeconds());
                  payload[field] = f;
                }
              }else if (field==='weight'){
                const num = parseFloat(newVal);
                if (isNaN(num)){ alert('权重必须为数字'); return; }
                payload[field] = num;
              }else if (field==='name'){
                payload[field] = newVal || '';
              }

              // if updating start/end or weight, we should recompute hours/points
              // fetch current row values from DOM
              const row = span.closest('tr');
              const startCell = row.querySelector('[data-field="start_at"]');
              const endCell = row.querySelector('[data-field="end_at"]');
              const weightCell = row.querySelector('[data-field="weight"]');
              // determine effective start/end/weight after edit
              let startVal = (field==='start_at') ? (payload['start_at'] || null) : (startCell ? (startCell.dataset.full || startCell.textContent) : null);
              let endVal = (field==='end_at') ? (payload['end_at'] || null) : (endCell ? (endCell.dataset.full || endCell.textContent) : null);
              let weightVal = (field==='weight') ? payload['weight'] : (weightCell? parseFloat(weightCell.textContent) : 1);
              // parse into Dates
              const pdStart = startVal ? parseLocalDateTime(startVal) : null;
              const pdEnd = endVal ? parseLocalDateTime(endVal) : null;
              if (pdStart && pdEnd){
                let hours = (pdEnd - pdStart)/3600000; if (hours < 0) hours += 24; hours = Math.round(hours*100)/100; payload['hours'] = hours; payload['points'] = Math.round(hours * (weightVal||1) * 100)/100;
              }else{
                // if end missing, clear hours/points
                if (payload.hasOwnProperty('start_at') || payload.hasOwnProperty('end_at')){ payload['hours'] = null; payload['points'] = null; }
              }

              // send update
              const res = await updateLabor(id, payload);
              if (!res || !res.ok){ alert('更新失败'); return; }
              // on success, update DOM cells: update span text and data-full
              if (payload[field] !== undefined){
                if (field==='start_at' || field==='end_at'){
                  const target = row.querySelector(`[data-field="${field}"]`);
                  const full = payload[field]===null? '' : payload[field];
                  target.dataset.full = full;
                  target.textContent = full? formatShort(full) : (field==='end_at'? '进行中' : '');
                }else{
                  const target = row.querySelector(`[data-field="${field}"]`);
                  target.textContent = payload[field];
                }
              }
              // update hours and points cells
              if (payload.hasOwnProperty('hours')){
                const hoursTd = row.children[4]; hoursTd.textContent = payload.hours? (payload.hours.toFixed? payload.hours.toFixed(2): payload.hours): '';
              }
              if (payload.hasOwnProperty('points')){
                const pointsTd = row.children[6]; pointsTd.textContent = payload.points? (payload.points.toFixed? payload.points.toFixed(2): payload.points): '';
              }
            }catch(err){ console.error(err); alert('更新出错'); }
          }
          input.addEventListener('blur', ()=>{ document.body.classList.remove('keyboard-open'); finish(true); });
          input.addEventListener('change', ()=>{ if (input.type==='datetime-local') finish(true); });
          input.addEventListener('keydown', (ev)=>{ if (ev.key==='Enter'){ ev.preventDefault(); finish(true); } if (ev.key==='Escape'){ finish(false); } });
        })();
      }

      // attach dblclick on spans and on their cells so double-clicking anywhere in the td starts edit
      historyEl.querySelectorAll('.editable').forEach(el=>{
        el.addEventListener('dblclick', (e)=>{ e.stopPropagation(); startEdit(e.currentTarget); });
      });
      // attach to td: if user dblclicks other parts of the cell, find editable child and start edit
      historyEl.querySelectorAll('tbody tr td').forEach(td=>{
        td.addEventListener('dblclick', (e)=>{
          // if a nested editable span handled it, it would have stopped propagation; otherwise handle here
          const span = td.querySelector('.editable');
          if (span) { startEdit(span); }
        });
      });

      // attach contextmenu (right-click) and long-press for touch to each table row for delete
      historyEl.querySelectorAll('tbody tr').forEach(tr=>{
        // find id from editable span
        const idSpan = tr.querySelector('.editable[data-id]');
        const id = idSpan ? idSpan.dataset.id : null;
        if (!id) return;

        // right-click handler
        tr.addEventListener('contextmenu', async (e)=>{
          e.preventDefault();
          const ok = await showConfirm('确定要删除这条记录吗？此操作不可撤销');
          if (!ok) return;
          const res = await deleteLabor(Number(id));
          if (!res || !res.ok){ alert('删除失败'); return; }
          // remove row from DOM
          tr.remove();
        });

        // touch long-press detection (mobile): trigger after 600ms press
        let touchTimer = null; let touchMoved = false;
        tr.addEventListener('touchstart', (e)=>{ touchMoved = false; touchTimer = setTimeout(async ()=>{ const ok = await showConfirm('确定要删除这条记录吗？此操作不可撤销'); if (!ok) return; const res = await deleteLabor(Number(id)); if (!res || !res.ok){ alert('删除失败'); return; } tr.remove(); }, 600); });
        tr.addEventListener('touchmove', ()=>{ touchMoved = true; if (touchTimer){ clearTimeout(touchTimer); touchTimer = null; }});
        tr.addEventListener('touchend', ()=>{ if (touchTimer){ clearTimeout(touchTimer); touchTimer = null; } });
      });
    }
    await refreshHistory();

    async function startTask(){
      const s = await getSession();
      if (!s){ alert('请先登录'); location.href='auth.html'; return; }
      const entry = {name:'', worker_wechat: s.wechat, start_at: nowISO(), end_at: null, weight:1, points:0};
      await createLabor(entry);
      bigBtn.innerText = '结束'; bigBtn.dataset.state='running';
      bigBtn.classList.add('running'); bigBtn.setAttribute('aria-pressed','true');
      await refreshHistory();
    }

    async function endTask(){
      const s = await getSession();
      if (!s) return;
  const labors = await loadLabors();
  // sort labors by start_at descending so newest are shown first
  labors.sort((a,b)=>{ const da = parseLocalDateTime(a.start_at) || 0; const db = parseLocalDateTime(b.start_at) || 0; return db - da; });
      // find last running task for this worker
      for (let i=labors.length-1;i>=0;i--){
        const e = labors[i];
        if (e.worker_wechat === s.wechat && !e.end_at){
          // preserve any existing name (user may have edited it in the recent list)
          const existingName = e.name || '';
          let nameToSave = existingName && String(existingName).trim() !== '' ? existingName : null;
          if (!nameToSave){
            const prompted = prompt('请输入劳动名称：','');
            nameToSave = (prompted && prompted.trim()) ? prompted.trim() : '未命名';
          }
          const end_at = nowISO();
          // compute hours; account for crossing midnight
          const st = parseLocalDateTime(e.start_at); const ed = parseLocalDateTime(end_at);
          let hours = (ed - st)/3600000;
          if (hours < 0) hours += 24; // simple midnight fix
          hours = Math.round(hours*100)/100;
          const points = Math.round(hours * (e.weight||1) * 100)/100;
          await updateLabor(e.id, {name: nameToSave, end_at, hours, points});
          break;
        }
      }
  bigBtn.innerText = '开始'; bigBtn.dataset.state='stopped';
  bigBtn.classList.remove('running'); bigBtn.setAttribute('aria-pressed','false');
      await refreshHistory();

      
    }

    // initialize button state: if there's running task
    const s = await getSession();
    const running = (await loadLabors()).some(l=>s && l.worker_wechat===s.wechat && !l.end_at);
    if (running){ bigBtn.innerText='结束'; bigBtn.dataset.state='running'; }
    else { bigBtn.innerText='开始'; bigBtn.dataset.state='stopped'; }

    // ensure visual ARIA state
    if (bigBtn.dataset.state === 'running'){ bigBtn.classList.add('running'); bigBtn.setAttribute('aria-pressed','true'); }
    else { bigBtn.classList.remove('running'); bigBtn.setAttribute('aria-pressed','false'); }

    // logout is handled in the navbar

    bigBtn.addEventListener('click', async ()=>{
      if (bigBtn.dataset.state === 'stopped') await startTask(); else await endTask();
    });
  }

  // Live page
  if (qs('usersTable')){
    function formatHours(h){ return (Math.round((h||0)*100)/100).toFixed(2); }
    // compute Beijing start-of-day and week-start (Monday) as Date objects representing the UTC instant
    function getTodayRange(){
      // now in UTC ms
      const nowMs = Date.now();
      // get Beijing local date by adding +8h
      const beijingMs = nowMs + 8*3600*1000;
      const bd = new Date(beijingMs);
      const startUtcMs = Date.UTC(bd.getFullYear(), bd.getMonth(), bd.getDate()) - 8*3600*1000;
      return new Date(startUtcMs);
    }
    function getWeekStart(){
      const nowMs = Date.now();
      const beijingMs = nowMs + 8*3600*1000;
      const bd = new Date(beijingMs);
      const day = bd.getDay();
      // compute Monday of this Beijing week
      const diff = bd.getDate() - day + (day===0?-6:1);
      const monday = new Date(bd);
      monday.setDate(diff);
      const startUtcMs = Date.UTC(monday.getFullYear(), monday.getMonth(), monday.getDate()) - 8*3600*1000;
      return new Date(startUtcMs);
    }

    async function refresh(){
      const users = await loadUsers();
      const labors = await loadLabors();
      const tbody = qs('usersTable').querySelector('tbody'); tbody.innerHTML='';
      const map = {};
      Object.keys(users).forEach(w=> map[w] = {nickname: users[w].nickname});
      // fill users from labors too
      labors.forEach(l=>{ if (!map[l.worker_wechat]) map[l.worker_wechat]={nickname:l.worker_wechat}; });

  const todayKey = getTodayKey();
  const weekKey = getWeekStartKey();

      // helper: compute numeric hours for a labor (fallback to computing from start/end)
      function laborHoursNum(l){
        if (!l) return 0;
        if (typeof l.hours === 'number') return l.hours;
        if (l.hours && !isNaN(Number(l.hours))) return Number(l.hours);
        if (l.start_at && l.end_at){
          const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          if (st && ed){ let hours = (ed - st)/3600000; if (hours < 0) hours += 24; return Math.round(hours*100)/100; }
        }
        return 0;
      }

      Object.keys(map).forEach(w=>{
        const nickname = map[w].nickname;
        const running = labors.some(l=>l.worker_wechat===w && !l.end_at);
        const todayHours = labors.reduce((s,l)=>{
          if (l.worker_wechat !== w || !l.end_at) return s;
          const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          if (!st || !ed) return s;
          const sk = beijingKeyFromDate(st); const ek = beijingKeyFromDate(ed);
          // include if any part of the labor falls on the target Beijing day
          if (sk === todayKey || ek === todayKey || (sk < todayKey && ek > todayKey)){
            return s + laborHoursNum(l);
          }
          return s;
        }, 0);
        const weekHours = labors.reduce((s,l)=>{
          if (l.worker_wechat !== w || !l.end_at) return s;
          const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          if (!st || !ed) return s;
          const sk = beijingKeyFromDate(st); const ek = beijingKeyFromDate(ed);
          // include if labor starts or ends in or spans into the week (simple overlap test)
          if ((sk && sk >= weekKey) || (ek && ek >= weekKey) || (sk < weekKey && ek > weekKey)){
            return s + laborHoursNum(l);
          }
          return s;
        }, 0);
        const tr = document.createElement('tr');
        tr.innerHTML = `<td>${nickname}</td><td>${running?'<strong style="color:#d00">忙碌</strong>':'休息'}</td><td>${formatHours(todayHours)}</td><td>${formatHours(weekHours)}</td>`;
        tbody.appendChild(tr);
      });

      // labors table
      const tbody2 = qs('laborsTable').querySelector('tbody'); tbody2.innerHTML='';
  labors.forEach(l=>{
        // prepare display values: start datetime, end datetime (short for UI)
        const startStr = l.start_at ? formatShort(l.start_at) : '';
        const endStr = l.end_at ? formatShort(l.end_at) : '';
        // compute hours in hours (float) if not provided
        let hoursVal = null;
        if (typeof l.hours === 'number') hoursVal = l.hours;
        else if (l.start_at && l.end_at){
    const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          let hours = (ed - st)/3600000;
          if (hours < 0) hours += 24; // simple midnight fix
          hoursVal = Math.round(hours*100)/100;
        }
        // compute points = hours * weight
        const weightVal = (typeof l.weight === 'number') ? l.weight : (l.weight ? Number(l.weight) : 1);
        const pointsVal = (hoursVal != null) ? Math.round((hoursVal * (weightVal||1))*100)/100 : (l.points || '');

        // show nickname when available, fallback to wechat id
  // prefer nickname stored on the labor record (if any), then users map, then fallback to wechat id
  const workerNick = l.nickname || ((map[l.worker_wechat] && map[l.worker_wechat].nickname) ? map[l.worker_wechat].nickname : l.worker_wechat);
  // show only nickname; fallback to wechat id if nickname absent
  const workerDisplay = workerNick || l.worker_wechat;
  const tr = document.createElement('tr');
  // new order: start, end, nickname, name, hours, weight, points
  tr.innerHTML = `<td>${startStr}</td><td>${endStr||'进行中'}</td><td>${workerDisplay}</td><td>${l.name||''}</td><td>${hoursVal!=null?hoursVal.toFixed(2):''}</td><td>${(weightVal||1).toFixed? (weightVal||1).toFixed(2) : (weightVal||1)}</td><td>${(typeof pointsVal === 'number')? pointsVal.toFixed(2) : pointsVal}</td>`;
        tbody2.appendChild(tr);
      });
    }
    await refresh();
    // bind exports
    async function exportCSV(range){
      const labors = await loadLabors();
      let filtered = labors;
      const todayStart = getTodayRange(); const weekStart = getWeekStart();
      if (range==='today') {
        const tkey = getTodayKey();
        filtered = labors.filter(l=>{ const k = beijingKeyFromString(l.start_at); return k && k === tkey; });
      }
      if (range==='week'){
        const wkey = getWeekStartKey();
        filtered = labors.filter(l=>{ const k = beijingKeyFromString(l.start_at); return k && k >= wkey; });
      }
    const rows = [['开始时间','结束时间','用户昵称','劳动名称','工时(小时)','权重','工分']];
      filtered.forEach(l=>{
  const startStr = l.start_at? formatLocal(l.start_at) : '';
  const endStr = l.end_at? formatLocal(l.end_at) : '';
        // compute hours if missing
        let hoursVal = l.hours;
        if ((hoursVal === undefined || hoursVal === null || hoursVal === '') && l.start_at && l.end_at){
          const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          let hours = (ed - st)/3600000; if (hours < 0) hours += 24;
          hoursVal = Math.round(hours*100)/100;
        }
        const weightVal = (l.weight!==undefined)? l.weight : 1;
        const pointsVal = (hoursVal !== undefined && hoursVal !== null && hoursVal !== '') ? Math.round(hoursVal * (weightVal||1) * 100)/100 : (l.points || '');
        const nick = l.nickname || l.worker_wechat;
        rows.push([startStr, endStr, nick, l.name||'', hoursVal||'', weightVal||1, pointsVal||'']);
      });
    // if nothing to export, let user know
    if (filtered.length === 0){ alert('没有可导出的记录'); return; }
    // load users to show nickname when available
    const usersMap = await loadUsers();
    // build HTML table rows (escape content)
    function escapeHtml(s){ return String(s===null||s===undefined?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;'); }
    const tableRows = filtered.map(l=>{
      const startStr = l.start_at? formatLocal(l.start_at) : '';
      const endStr = l.end_at? formatLocal(l.end_at) : '';
      let hoursVal = l.hours;
      if ((hoursVal === undefined || hoursVal === null || hoursVal === '') && l.start_at && l.end_at){
        const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
        let hours = (ed - st)/3600000; if (hours < 0) hours += 24;
        hoursVal = Math.round(hours*100)/100;
      }
      const weightVal = (l.weight!==undefined)? l.weight : 1;
      const pointsVal = (hoursVal !== undefined && hoursVal !== null && hoursVal !== '') ? Math.round(hoursVal * (weightVal||1) * 100)/100 : (l.points || '');
      // prefer nickname on the labor record, then usersMap, then wechat id
      const workerNick = l.nickname || ((usersMap && usersMap[l.worker_wechat] && usersMap[l.worker_wechat].nickname) ? usersMap[l.worker_wechat].nickname : l.worker_wechat);
      const workerDisplay = workerNick || l.worker_wechat;
      // return columns in new order: start, end, nickname, name, hours, weight, points
      return `<tr><td>${escapeHtml(startStr)}</td><td>${escapeHtml(endStr)}</td><td>${escapeHtml(workerDisplay)}</td><td>${escapeHtml(l.name||'')}</td><td>${escapeHtml(hoursVal||'')}</td><td>${escapeHtml(weightVal||1)}</td><td>${escapeHtml(pointsVal||'')}</td></tr>`;
    }).join('');

  const html = `<!doctype html><html><head><meta charset="utf-8" /></head><body><table border="1"><thead><tr><th>开始时间</th><th>结束时间</th><th>用户昵称</th><th>劳动名称</th><th>工时(小时)</th><th>权重</th><th>工分</th></tr></thead><tbody>${tableRows}</tbody></table></body></html>`;
    // prepend BOM for safety, and set MIME to Excel
    const bom = '\uFEFF';
    const blob = new Blob([bom + html], {type: 'application/vnd.ms-excel;charset=utf-8;'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a'); a.href = url;
    const now = new Date();
    function fmtDateLocal(d){ return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
    const fname = (range==='today' ? 'labors_today' : (range==='week' ? 'labors_week' : 'labors')) + '_' + fmtDateLocal(now) + '.xls';
    a.download = fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
    }
    // bind to an existing export button in the HTML if present (id="exportRange")
    // do not create elements programmatically per request
    let exportBtn = qs('exportRange') || qs('exportRangeBtn');
    // if neither exists, create nothing; the export dialog can still be triggered programmatically if needed

    // create modal dialog (simple) for start/end date input
    function createExportDialog(){
      // if already exists, return
      if (qs('exportRangeModal')) return;
      const modal = document.createElement('div'); modal.id='exportRangeModal';
      modal.style.position='fixed'; modal.style.left=0; modal.style.top=0; modal.style.right=0; modal.style.bottom=0; modal.style.background='rgba(0,0,0,0.4)'; modal.style.display='none'; modal.style.alignItems='center'; modal.style.justifyContent='center'; modal.style.zIndex=99999;
      const box = document.createElement('div'); box.style.background='white'; box.style.padding='16px'; box.style.borderRadius='8px'; box.style.minWidth='300px'; box.style.boxShadow='0 8px 24px rgba(0,0,0,0.2)';
      box.innerHTML = `<h3>导出记录</h3><div style="display:flex;flex-direction:column;gap:8px"><label>开始日期 <input id="export_start" type="date" /></label><label>结束日期 <input id="export_end" type="date" /></label><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px"><button id="export_cancel" class="btn">取消</button><button id="export_ok" class="btn primary">导出</button></div></div>`;
      modal.appendChild(box); document.body.appendChild(modal);
      qs('export_cancel').addEventListener('click', ()=>{ modal.style.display='none'; });
      qs('export_ok').addEventListener('click', async ()=>{
        const s = qs('export_start').value; const e = qs('export_end').value;
        if (!s || !e){ alert('请填写开始和结束日期'); return; }
        // construct full datetime range in Beijing local: start 00:00:00, end 23:59:59
        const startDT = s + ' 00:00:00';
        const endDT = e + ' 23:59:59';
        modal.style.display='none';
        await exportCSVRange(startDT, endDT);
      });
    }
    createExportDialog();

    if (exportBtn){
      exportBtn.addEventListener('click', ()=>{ const m = qs('exportRangeModal'); if (m) m.style.display='flex'; });
    }

    // export by explicit datetime range (DB-format strings 'YYYY-MM-DD HH:MM:SS' assumed local)
    async function exportCSVRange(start_dt_str, end_dt_str){
      // ask server to return only labors that fall in or overlap the requested range
      // the server expects start_dt and end_dt as query params in 'YYYY-MM-DD HH:MM:SS' local format
      const params = new URLSearchParams();
      params.set('action','labors');
      params.set('start_dt', start_dt_str);
      params.set('end_dt', end_dt_str);
  const apiUrl = 'api.php?' + params.toString();
  const r = await fetch(apiUrl, {credentials:'include'});
      if (!r.ok){ alert('从服务器获取记录失败'); return; }
      const j = await r.json();
      const filtered = j.labors || [];
      if (filtered.length === 0){ alert('没有可导出的记录'); return; }
      const usersMap = await loadUsers();
      function escapeHtml(s){ return String(s===null||s===undefined?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
      const tableRows = filtered.map(l=>{
        const startStr = l.start_at? formatLocal(l.start_at) : '';
        const endStr = l.end_at? formatLocal(l.end_at) : '';
        let hoursVal = l.hours;
        if ((hoursVal === undefined || hoursVal === null || hoursVal === '') && l.start_at && l.end_at){
          const st = parseLocalDateTime(l.start_at); const ed = parseLocalDateTime(l.end_at);
          let hours = (ed - st)/3600000; if (hours < 0) hours += 24; hoursVal = Math.round(hours*100)/100;
        }
        const weightVal = (l.weight!==undefined)? l.weight : 1;
        const pointsVal = (hoursVal !== undefined && hoursVal !== null && hoursVal !== '') ? Math.round(hoursVal * (weightVal||1) * 100)/100 : (l.points || '');
        const workerNick = l.nickname || ((usersMap && usersMap[l.worker_wechat] && usersMap[l.worker_wechat].nickname) ? usersMap[l.worker_wechat].nickname : l.worker_wechat);
        const workerDisplay = workerNick || l.worker_wechat;
        return `<tr><td>${escapeHtml(startStr)}</td><td>${escapeHtml(endStr)}</td><td>${escapeHtml(workerDisplay)}</td><td>${escapeHtml(l.name||'')}</td><td>${escapeHtml(hoursVal||'')}</td><td>${escapeHtml(weightVal||1)}</td><td>${escapeHtml(pointsVal||'')}</td></tr>`;
      }).join('');
      const html = `<!doctype html><html><head><meta charset="utf-8" /></head><body><table border="1"><thead><tr><th>开始时间</th><th>结束时间</th><th>用户昵称</th><th>劳动名称</th><th>工时(小时)</th><th>权重</th><th>工分</th></tr></thead><tbody>${tableRows}</tbody></table></body></html>`;
      const bom = '\uFEFF';
      const blob = new Blob([bom + html], {type: 'application/vnd.ms-excel;charset=utf-8;'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a'); a.href = url;
      const now = new Date();
      function fmtDateLocal(d){ return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
      const fname = 'labors_range_' + fmtDateLocal(now) + '.xls';
      a.download = fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
    }
  }

})();
